feat: in-browser OPFS LibreDB playground at /playground (#19)#20
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…19) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed (#19) Verified in Chromium on localhost: - seed renders 3 users on first load; badge reads OPFS · persistent - cheatsheet insert -> 4th row; survives reload (OPFS durable) - reset returns to seed; kv prefix + doc.find scans render - 0 console errors; no node:fs in client bundle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- add 'playground' section (schema: database) so the Explorer tree shows it as a page under the pre-alpha database group, where users expect it - exclude 'playground' from [section].astro getStaticPaths (served by its own interactive page, avoids duplicate-route collision) - mark the page active='playground' so the sidebar row highlights - update sections test for the new database-group page Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… converter (#19) LibreDB is an ordered key-value store; tables and document collections are conventions over the keyspace (users:<pk>, articles:<id>) recorded in the catalog — not separate command dialects. The previous design invented a SQL-ish (select/insert) + doc.* translator that does not exist in LibreDB or Studio's LibreDBProvider, misrepresenting the engine and adding a maintenance liability. Now mirrors docs/providers/libredb.md exactly: - one grammar = the five kv verbs: get/put/delete/prefix/range - quote-aware tokenizer; case-insensitive verbs; #-comment/blank-line skipping - prefix/range hide the reserved catalog namespace via isReservedKey - JSON values pretty-printed in the grid (renderValue) - seed still writes through doc()/table() so the catalog is real; the visitor operates every namespace through the same kv verbs - cheatsheet regrouped by namespace (users:* relational, articles:* document, config:* kv) with a teaching note; reset is a sandbox action, not a verb Browser-verified: prefix users: shows rows as keys+JSON; put users:4 persists; range a z scans all namespaces in byte order with no reserved-key leak; 0 console errors. Gate: 49 tests, 0 type errors, lint/format/knip clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…of auto-running (#19) Clicking a cheatsheet command now fills the editor, clears the previous result, shows a 'Press Run' hint, and focuses the input — so visitors can read/edit the command before executing it. Run / Ctrl+Enter still execute. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orted) (#19) Mirrors libredb-database/docs/CLI.md's database-level commands, all fully supported by the browser build: - inspect: lists catalogued namespaces + kind + relational schema via catalog(db) - stats: file size (OPFS handle.getSize(), worker-supplied) + per-kind counts - import <json>: bulk-set a JSON object of string values in ONE db.transact(); refuses reserved keys via isReservedKey Shown as a separate 'Manage' (admin) group in the cheatsheet, each with a use-case line; clicking loads into the editor (then Run), consistent with the other commands. CLI file concepts (path, .lock, --force) have no browser analog and are intentionally omitted; scan/set are not aliased (prefix/put stay canonical). Browser-verified: inspect shows users=relational(+schema)/articles=document; stats shows 722 bytes, 2 namespaces, kv 0/doc 1/rel 1; import persists across reload (atomic + durable); 0 console errors. Gate: 57 tests, 0 type errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
) The import sample used user:9 (singular) right above the users:* table (plural), reading as if it wrote to that table — but import does raw kv writes. Switched the example to session:a1/a2 (a clearly new namespace) so it no longer collides visually with the seeded users table. Aligned the protocol test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gible (#19) Queries finish in <1ms, so the grid swapped with no perceptible feedback — re-running looked like nothing happened (change blindness). Instead of faking a spinner delay (which would hide the engine's speed), add two honest cues: - a status/echo strip above the grid: '▸ <command> · N rows · X ms', updated every run and acting as the screen-reader announcer (grid aria-live removed to avoid double-announce) - a subtle 180ms fade+slide-in (pg-result-in), re-triggered on every render so even an identical re-run is visibly new; disabled under prefers-reduced-motion Browser-verified: status shows '▸ prefix users: · 3 rows · 34ms' then updates to '▸ prefix config: · 3 rows · 13ms' on the next run; 0 console errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…column) (#19) The put users:4 {"id":"4",...} example shows the id twice, which reads as redundant. It is faithful — table.insert stores a row at key <table>:<pk> and keeps the pk as a column, so seeded rows look the same. Added a one-line note under the users:* group explaining the convention rather than hiding it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new public /playground route to the Astro site that runs a real LibreDB database fully in-browser (OPFS-backed when available) via a Web Worker, with a small UI (editor + results + cheatsheet), seeded sample data, and Bun unit tests for the parser/engine.
Changes:
- Introduces a worker-based OPFS/in-memory LibreDB runtime plus a main-thread client bridge for running commands and rendering results.
- Adds
/playgroundpage + StudioShell UI components (Playground + Cheatsheet) and navigation/sections metadata updates. - Adds Bun unit tests for the command protocol/parser and execution engine; adds
@libredb/libredb@0.1.3dependency.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/styles/global.css | Adds result fade/slide animation class used by the playground UI (reduced-motion aware). |
| src/scripts/playground/protocol.ts | Defines command grammar + worker message protocol and a parser for supported commands. |
| src/scripts/playground/protocol.test.ts | Adds Bun unit tests covering parsing behavior and error cases. |
| src/scripts/playground/engine.ts | Implements seed data and command execution against LibreDB (kv + catalog-based admin commands). |
| src/scripts/playground/engine.test.ts | Adds Bun unit tests for seed + execute behavior against in-memory LibreDB. |
| src/scripts/playground/db.worker.ts | Web Worker that acquires OPFS handle (or falls back to memory), seeds, executes commands, and supports reset/close. |
| src/scripts/playground/client.ts | Main-thread worker bridge + DOM rendering (status strip, grid, cheatsheet interactions, lifecycle teardown). |
| src/pages/playground.astro | Adds the /playground route wiring Layout + StudioShell + Playground component. |
| src/pages/[section].astro | Excludes playground from dynamic section routes to avoid clashing with the dedicated page. |
| src/data/sections.ts | Adds playground section metadata for sidebar/explorer grouping under the database schema. |
| src/data/sections.test.ts | Updates tests to include the new playground database-schema section. |
| src/components/studio/TopBar.astro | Adds a top-bar navigation link to /playground. |
| src/components/studio/Playground.astro | New playground UI shell (editor, run/reset, status strip, results, console, banner/badge). |
| src/components/studio/Cheatsheet.astro | New clickable cheatsheet sidebar including admin/manage commands. |
| package.json | Adds @libredb/libredb@0.1.3 dependency. |
| docs/superpowers/specs/2026-06-30-playground-opfs-editor-design.md | Adds design/spec documentation for the playground feature. |
| docs/superpowers/plans/2026-06-30-playground-opfs-editor.md | Adds a detailed implementation plan and verification checklist. |
| bun.lock | Locks the added @libredb/libredb@0.1.3 dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case 'put': { | ||
| const r = kv(db).set(cmd.key, cmd.value); | ||
| return { kind: 'message', message: `OK · changed ${r.changed}` }; | ||
| } |
| case 'delete': { | ||
| const r = kv(db).delete(cmd.key); | ||
| return { kind: 'message', message: `OK · changed ${r.changed}` }; | ||
| } |
| // Free the exclusive OPFS lock so a reload reacquires cleanly. | ||
| window.addEventListener('pagehide', () => { | ||
| void call({ op: 'close' }); | ||
| worker.terminate(); | ||
| }); |
Previously only write commands (put/delete/import) and errors hit the console; reads (get/prefix/range/inspect/stats) rendered to the grid and were never logged — inconsistent and sparse. Now EVERY run appends one structured entry, newest-first: HH:MM:SS ✓/✗ <command> <summary> <ms> <source> - time (HH:MM:SS; full locale date+time on hover via title) - status (✓ success / ✗ error) - command, outcome summary (N rows / changed N / error text) - duration (ms), and trigger source (editor / system) - capped at 50 entries; cheatsheet-load no longer wipes the log (it's history) The status strip stays as the grid's current-result header and remains the sole aria-live announcer (log aria-live dropped to avoid double announcements). Browser-verified: read/write/error all log correctly with source + timing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… worker teardown race (#19) - put/delete now refuse reserved-namespace keys, matching import and Studio's CLI safety rule (the raw kv lens is unguarded, so a U+0000-prefixed key could otherwise overwrite/remove catalog entries and corrupt the sandbox) - worker teardown: on 'close' the worker now db.close()s and self.close()s; the client no longer calls worker.terminate() on pagehide (it raced and usually won before the worker ran db.close(), so the OPFS lock wasn't released cleanly) - pagehide skips bfcache (event.persisted) so a back/forward restore keeps a live worker - tests: put/delete reserved-key refusal (58 total) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @copilot — all three addressed in 52e9a6e:
Added unit tests for the |
| export function isSeeded(db: Database): boolean { | ||
| return kv(db).get('config:theme') !== undefined; | ||
| } |
| | `src/scripts/playground/seed.ts` | Sample dataset definition + a `seed(db)` function (imported by the worker). | | ||
| | `src/scripts/playground/db.worker.ts` | The Worker. Owns the OPFS handle, opens db (durable→memory fallback), seeds on first open, dispatches parsed commands to lenses, returns results, `db.close()` on teardown. | | ||
| | `src/scripts/playground/client.ts` | Main-thread bridge: spawns worker, `call()` request/response with ids, wires Run button + cheatsheet clicks + Reset, renders result grid, shows fallback banner, posts `close` + `terminate()` on `pagehide`. | | ||
| | `src/scripts/playground/protocol.test.ts` | `bun:test` unit tests for the parser/grammar. | |
| 5. Worker parses, executes against the right lens, posts `{id, result}` or `{id, error}`. | ||
| 6. Client renders rows in the grid; errors → red console toast. | ||
| 7. Reset → `call(op:"reset")` → worker truncates the relevant keys/tables and reseeds. | ||
| 8. `pagehide` → `call(op:"close")` then `worker.terminate()`. |
| // Free the exclusive OPFS lock so a reload reacquires cleanly. | ||
| window.addEventListener("pagehide", () => { | ||
| void call({ op: "close" }); | ||
| worker.terminate(); | ||
| }); |
… docs (#19) Round 2 of Copilot review: - isSeeded() keyed off config:theme, which a visitor can delete — then a reload saw the db as unseeded and re-ran seed(), clobbering edited users:*/articles:* rows. Now isSeeded() = catalog(db).size > 0: the catalog is reserved (and write-protected), survives row deletions, and can't be emptied by user commands, so a deleted kv key never triggers a re-seed. (+regression test) - spec Files table: replaced the non-existent seed.ts row with engine.ts (seed lives there) and added engine.test.ts - spec + plan teardown: corrected to the shipped semantics — worker self-closes on 'close'; client does not worker.terminate() on pagehide Browser-verified: fresh OPFS origin seeds correctly. Gate: 59 tests, 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Round 2 — all four addressed in 1d0ff40:
Gate: 59 tests, 0 type errors; fresh-origin browser check confirms seeding still works. |
From the external STRIDE/UX report (non-blocking items):
- parseImport: Object.create(null) accumulator so a literal "__proto__" key
imports as an own property instead of silently no-op'ing on the prototype
setter (+test). Not a pollution vuln — values are string-constrained.
- Run: disable + "… Running" while a query is in flight; a busy guard ignores
re-entrant runs (fixes double-click / repeated Ctrl+Enter sending 2 messages).
- Reset: inline two-step confirm ("Click again to confirm", 3s disarm) so a
destructive wipe needs intent — no blocking dialog.
- stats: exhaustive switch over catalog kinds with a never-guard (a future kind
becomes a compile error instead of being miscounted as kv).
- activity log height max-h-32 -> max-h-48; Run gets disabled: styling.
- TopBar /playground link reflects active state via studio.ts syncActive
(the bar is transition:persist'd, so SSR-time active would go stale under VT).
Note: the report's mobile order- suggestion was not needed — the editor section
already precedes the cheatsheet in the DOM, so it renders first on mobile.
Gate: 60 tests, 0 type errors; browser-verified busy state, two-step reset,
active topbar link.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per request — protect destructive writes without confirm-dialog friction: - L0 reserved keys: hard-refused (unchanged) - L1 create (new put) / no-op (delete of a missing key): silent success (✓) - L2 destructive (overwrite an existing value / delete an existing key): runs immediately but is flagged 'warn' (⚠, warn colour in the status strip + log) and echoes the PRIOR value, so an accidental clobber is visible and recoverable (re-put it to undo) RunResult.message gains an optional level: 'ok' | 'warn'; the activity log and status strip render ✓/⚠/✗ with ok/warn/bad colours. engine echoes a compact single-line preview() of the prior value. Browser-verified: created/overwrote(was:dark)/deleted(was:en)/not-found all render with the right icon + level; 0 console errors. Gate: 60 tests, 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| async function doReset(): Promise<void> { | ||
| const res = await call({ op: 'reset' }); | ||
| if (res.kind === 'result') render(res.result, 'reset', 0, 'system'); | ||
| await run('prefix users:', 'system'); | ||
| } |
| <!-- Result grid --> | ||
| <div data-pg-grid class="min-h-0 flex-1 overflow-auto" aria-label="Query results"></div> |
| <div | ||
| data-pg-log | ||
| class="max-h-48 shrink-0 space-y-1 overflow-auto border-t border-edge bg-canvas px-4 py-2 font-mono text-[12px]" | ||
| aria-label="Activity log" | ||
| > |
| self.onmessage = async (event: MessageEvent<WorkerRequest>) => { | ||
| await ready; | ||
| const msg = event.data; | ||
| try { |
…les (#19) Round 3 of Copilot review: - doReset() now shares the single in-flight guard with run(): both Run and Reset are disabled for the duration, and the post-reset refresh runs via an unguarded exec() path (previously it early-returned when a query was still busy, leaving the grid stale). Reset can no longer overlap a query. - db.worker.ts: serialize all message handling on one promise chain (starting with boot) so only one op touches db at a time — a run can't land mid-reset and close can't race an in-flight op. execute is sync so the prior worst case was a caught error, but this makes the worker robustly serial regardless of caller. - a11y: give the results grid and activity log role="region" so their aria-label names are reliably exposed (a bare div+aria-label isn't). Deliberately NOT role="log" — that implies a live region and would re-introduce double announcements with the status strip. Browser-verified: reset disables both buttons then refreshes to seed; grid/log expose region+name; 0 console errors. Gate: 60 tests, 0 type errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Round 3 — all four addressed in cbc3aff:
Browser-verified: reset disables both buttons then refreshes to seed; grid/log expose region+name; 0 console errors. Gate: 60 tests, 0 type errors. |
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Adds a public
/playgroundroute that runs a real LibreDB database entirely in the browser — no backend, no login. The engine (@libredb/libredb@0.1.3, browser entry) runs in a Web Worker backed by OPFS, preloaded with sample data across all three lenses, with a clickable command cheatsheet beside the editor.Closes #19.
Listed in the sidebar Explorer under the database group; not on the homepage (the marketing hero editor is unchanged).
What it does
playground.libredbin a Worker; writes persist across reloads; per-visitor isolated; zero backend.LibreDBProviderexactly — the five kv verbsget / put / delete / prefix / range(quote-aware tokenizer,#-comment skipping,isReservedKeyfiltering on scans, JSON value pretty-printing). No invented SQL/document dialect — tables/collections are conventions over the keyspace (users:<pk>,articles:<id>) recorded in the catalog.libredb-database/docs/CLI.md):inspect(catalog namespaces + schema),stats(file size + per-kind counts),import <json>(bulk-set in one atomicdb.transact()), shown in a Manage group with use-case lines.userstable,articlescollection,config:*kv — written throughdoc()/table()so the catalog is real.db.close()onpagehidefrees the lock for clean reacquire.navigator/Workertouched only after load.UX details
▸ <command> · N rows · X ms) + a subtle 180ms fade-in (re-triggered each run,prefers-reduced-motionaware) make an instant query legible without faking a spinner delay.Architecture
Pure, unit-tested modules hold the logic:
protocol.ts(grammar/parse) andengine.ts(lens dispatch) — both run underbun:testagainst the in-memoryopen()(the browser entry imports nonode:).Testing
bun:testunit tests (parser + engine against a real in-memory db).bun run gategreen: typecheck (0 errors), prettier, oxlint, knip, tests.OPFS · persistent;put/update/deletework and persist across reload;range a zscans all namespaces in byte order with no reserved-key leak;inspect/stats/importcorrect; nonode:fsin the client bundle; 0 console errors.Notes
docs/superpowers/.🤖 Generated with Claude Code